UOJ Logo _rqy的博客

博客

标签
暂无

NOI2024 D1T1 集合 题解

2024-07-19 10:13:55 By _rqy

固定区间 $[l, r]$, 不妨设就是 $[1, n]$, 我们来看有解的充要条件.

先假设有解, 思考怎么找出解. 试想对于一个 $x$, 我们找出所有含有 $x$ 的 $A_i$. 那么 $p(x)$ 必然在所有对应的 $B_i$ 之内.

  1. 假如这些 $B_i$ 的交集没有元素, 那么必然无解;
  2. 假如这些 $B_i$ 的交集是 $\{ x' \}$, 那么必然有 $p(x) = x'$.
  3. 假如这些 $B_i$ 的交集是 $\{ x', y' \}$, 那么 $A_i$ 的交集大小也得是 $2$, 设为 $\{ x, y \}$.
  4. 假如这些 $B_i$ 的交集是 $\{ x', y', z' \}$, 那么 $A_i$ 的交集大小也得是 $3$, 设为 $\{ x, y, z \}$.

如果我们从出现次数最多的 $x$ 开始, 那么在情况 3 中, $x$ 和 $y$ 的出现位置完全相同, 因此可以随意地令 $p(x)=x', p(y)=y'$, 或者 $p(x)=y',p(y)=x'$.

因此我们得到一个条件: 任取一些 $i$, 对应的 $A_i$ 的交集大小等于对应的 $B_i$ 的交集大小. 写的形式一点就是 $$ \forall k, \forall i_1, i_2, \dots, i_k, \bigl| A_{i_1} \cap A_{i_2} \cap \dots \cap A_{i_k} \bigr| = \bigl| B_{i_1} \cap B_{i_2} \cap \dots \cap B_{i_k} \bigr|. $$

事实上这个条件是充要的. 假设这个条件成立, 我们可以按出现次数从大到小考虑所有 $x$ 并分配 $p(x)$; 分配完之后把所有 $A_i$ 中的 $x$ 删除, 所有 $B_i$ 中的 $p(x)$ 删除, 剩下的集合序列仍然满足这个条件因此可以重复地分配直到全为空.

考虑怎么维护这个条件. 我们注意到这个条件进一步等价于六个条件:

  1. 如果一些 $A_i$ 的交集大小大于等于 $1$, 则对应的 $B_i$ 的交集大小也大于等于 $1$;
  2. 如果一些 $A_i$ 的交集大小大于等于 $2$, 则对应的 $B_i$ 的交集大小也大于等于 $2$;
  3. 如果一些 $A_i$ 的交集大小大于等于 $3$, 则对应的 $B_i$ 的交集大小也大于等于 $3$.

以及 $A, B$ 互换后的三个条件. 而这三个条件又等价于

  1. 对任意的 $x$, 存在 $x'$, 使得 $x \in A_i \implies x' \in B_i$. (考虑包含 $x$ 的所有 $A_i$ 的交集, 下面类似)
  2. 对任意两个不同的 $x, y$, 存在不同的 $x', y'$, 使得 $x, y \in A_i \implies x', y' \in B_i$.
  3. 对任意三个不同的 $x, y, z$, 存在 $x', y', z'$, 使得 $x, y, z \in A_i \implies x', y', z' \in B_i$.

因此我们双指针往前跑, 同时对每个 $x$ 维护 $x'$ 的候选, 对每组 $(x, y)$ 维护 $(x', y')$ 的候选, 对每组 $(x, y, z)$ 维护 $(x', y', z')$ 的候选即可. (注意到有用的 $(x, y)$ 组只有 $3n$ 种)

找到所有有用的 $(x, y)$ 组并给他们标号可以用桶排做线性.

然后把 $A$ 和 $B$ 反过来也维护起来, 就可以了.

反正这个维护候选的结构很容易做到线性, 就不赘述了. 总复杂度 $O(n)$.

随机 01 序列的最长不降子序列长度期望

2024-07-18 23:24:00 By _rqy

设有一个 $01$ 序列 $s$. 记

  • $L$ 是 $s$ 的最长不降子序列的长度,
  • $S$ 为 $s$ 中 $1$ 的个数,
  • $T = max_i (\text{$s[1 \dots i]$ 里 $0$ 的个数减 $1$ 的个数})$.

那么 $L=S+T$, 因为 $$L = max_i (\text{$s[1 \dots i]$ 中 $0$ 的个数} + \text{$s[i+1\dots n]$中$1$的个数}).$$

所以按线性性 $E(L)=E(S)+E(T)$, 显然 $E(S)=n/2$, 因此只需要计算$E(T)$.

显然 $T≥0$, 因为可以取$i=0$. 考虑 $T\geq k$ 的方案数. 这就相当于在平面上, 从 $(0,0)$ 出发, 每次可以往右上或者右下走一步, 问走 $n$ 步途中至少碰到一次水平线 $y=k$ 的方案数. 那么有两种可能:

  • 最终落点的 $y \geq k$. 这部分贡献是 $\sum_{2a \leq n-k} \binom{n}{a}$, $a$ 是枚举往右下走的步数.
  • 最终落点的 $y < k$. 根据反射法这等于从 $(2k,0)$ 出发走 $n$ 步最终到达 $y < k$ 位置的方案数, 是 $\sum_{2a < n-k} \binom{n}{a}$, $a$ 是枚举往右上走的步数.

因此 $T \geq k$ 的概率是上面两个式子加起来除以 $2^n$.

所以 $T$ 的期望是 $$\begin{aligned} &\sum_{k=1}^n P(T \geq k) \\ =& \frac{1}{2^n} \sum_{k=1}^n \left( \sum_{2a \leq n-k} \binom{n}{a} + \sum_{2a < n-k} \binom{n}{a} \right) \\ =& \frac{1}{2^n} \sum_{a=0}^{\lfloor (n-1)/2 \rfloor} \binom{n}{a} (2n-4a-1) \\ \end{aligned}$$

显然这是个整式递推, 可以 $O(n)$ 计算. 事实上设 $A_n$ 是 $T$ 的期望, 那么

$$A_{2n} - A_{2n-1} = A_{2n+1} - A_{2n} = \frac14 + \frac{1}{2^{2n+2}}\binom{2n}{n}.$$

UR#24 T1题解

2023-02-19 19:49:31 By _rqy

考虑判断一个字符串 $s$ 是否满足条件.

令 $G(s)_c$ 表示 $s$ 的以 $c$ 结尾的本质不同子序列个数, $G(s)_0$ 表示总的本质不同子序列个数. 那么若 $s = s' + a$ (字符串拼接), 则有

$$G(s)_c = \begin{cases} G(s')_0 & c = a, \\ G(s')_c & c \ne a, 0. \end{cases}$$

$$\begin{aligned} G(s)_0 &= 1 + \sum_{c \ne 0} G(s)_c \\ &= 1 + \sum_{c \ne 0, a} G(s')_c + G(s')_0 \\ &= 2G(s')_0 - G(s')_a \equiv G(s')_a \pmod{2} \end{aligned}$$

我们发现, 在模 $2$ 意义下在字符串后面加一个字符 $a$ 就是交换 $G$ 中 $0$ 和 $a$ 的值. 而最初的时候 $G(\varnothing)_c = [c=0]$, 所以只需要判断最后是否把 $0$ 换回 $0$.

那么我们在树上做的时候,只需要对每个结点 $i$ 和每个字符 $c$ 维护:

  • 子树内有多少结点 $j$, 满足从 $i$ 走到 $j$ 会把 $G(s')_c$ 换到 $G(s)_0$;
  • 子树内有多少结点 $j$, 满足从 $j$ 走到 $i$ 会把 $G(s')_0$ 换到 $G(s)_c$.

两棵子树合并的时候把对应位置乘起来统计答案, 往上走的时候交换两个位置的值即可.

更新:

可以发现连放两个相同字符等于不放. 所以可以把 i 到 j 改成 i 到根再到 j.

dfs 时可以维护当前点到根的置换. 这样就可以 $O(n)$ 求出每个点到根会把 $0$ 换成谁 (这也等价于从根到它会把谁换成 $0$). 最后统计答案即可.

线段树维护分治信息略解

2023-02-17 02:39:58 By _rqy

前言

今日又在 UOJ 群中看到有小友提问线段树维护信息的“双半群”性质云云,遂写此文,略解线段树。

其实与其说略解线段树,倒不如说略解区间分治信息维护,毕竟树状区间数据结构,诸如线段树,Splay,Treap等,都遵同理。

若读此文,读者需预先了解何为线段树,最好应该写过例题

维护一序列 $A_i$,支持区间乘,区间加,查询区间和。

或读者自认为难度仿佛的习题。不然可能难以理解本文所说的线段树结构以及实现。

本文同时发布于洛谷:铃悬的博客, 为我与铃悬合作创作。

标记和信息

众所周知——也或者没有那么周知,线段树通过打标记维护信息的时候,标记和信息分别要构成半群,并且之间的作用要具有分配律,此之谓“双半群”。具体地说,

  • 线段树每个结点上有一个“标记”,其具有类型 $T$,或者说属于集合 $T$。
  • 每个结点还有一个“信息”,其具有类型 $D$,或者说属于集合 $D$。
  • 由于我们要合并信息,必须有一个合并函数 $\operatorname{merge}(D, D) \to D$。如果我们维护的是区间和,那么合并就是加法。
  • 如果当前结点有懒标记,那么为了得出这个结点的信息(也就是所谓的 pushup 或者 update),除了要合并两个子树的信息之外,还要考虑合并之后的信息在这个懒标记应用上去之后变成什么。这就要求我们有一个“作用”函数 $\operatorname{apply}(T, D) \to D$。
  • 打标记或者下传标记的时候,有可能要打标记的结点原来就有标记。这就需要我们知道两个标记先后作用之后会变成什么标记,也就是一个标记复合函数 $\operatorname{compose}(T, T) \to T$。如果对一个区间先打上 $t_1$ 标记,再打上 $t_2$ 标记,结果应该和打上 $\operatorname{compose}(t_2, t_1)$ 标记相同。

举个例子。假设我们操作是区间乘和区间加,要维护区间和。那么: - 标记 $T$ 就形如 $(a, b)$,表示要把区间里每个数 $x$ 都变成 $ax+b$。 - 信息 $D$ 就形如 $(l, s)$,其中 $l$ 表示区间长度,$s$ 表示区间和。虽然其实这个 $l$ 永远不会变所以不用维护,我们只是形式地这么写一下而已。 - 信息的合并就是简单的 $\operatorname{merge}((l_1, s_1), (l_2, s_2)) = (l_1 + l_2, s_1 + s_2)$. - 标记对信息的作用是 $\operatorname{apply}((a, b), (l, s)) = (l, as + bl)$。 - 标记的复合是 $\operatorname{compose}((a_1, b_1), (a_2, b_2)) = (a_1a_2, a_1b_2 + b_1)$。注意我们的符号是先作用第二个再作用第一个。

如果我们只有区间乘,那标记就是一个数表示区间乘 $x$,信息就是一个和 $s$。合并就是加法,作用和复合都是乘法。因此,我们在一般情况下,也用加法符号 $+$ 表示合并,乘法符号 $\times$ 或者 $\ast$ 表示作用和复合。

上面的例子就变成 - $(l_1, s_1) + (l_2, s_2) = (l_1 + l_2, s_1 + s_2)$. - $(a, b) \ast (l, s) = (l, as+bl)$. - $(a_1, b_1) \ast (a_2, b_2) = (a_1a_2, a_1b_2 + b_1)$.

“双半群”结构

接下来我们想一想标记和信息组成的这个结构,或者说系统,具体要满足哪些条件。

  1. 首先,区间信息合并的先后顺序是无所谓的。如果有三个区间 $[l, x], [x+1, y], [y+1,r]$, 他们对应的信息分别是 $d_1, d_2, d_3$, 那么总应该有 $$\operatorname{merge}(\operatorname{merge}(d_1, d_2), d_3) = \operatorname{merge}(d_1, \operatorname{merge}(d_2, d_3)).$$ 或者说 $(d_1 + d_2) + d_3 = d_1 + (d_2 + d_3)$. 也就是说信息的合并满足结合律.
  2. 标记也应该有结合律:$t_1(t_2t_3) = (t_1t_2)t_3$.
  3. 而且,根据我们“作用 $\operatorname{compose}(t_1, t_2)$ 等价于先作用 $t_2$ 再作用 $t_1$”的原则,总应该有 $(t_1t_2)d = t_1(t_2d)$.

    这也就是为什么我们规定成先 $t_2$ 后 $t_1$,不然写起来顺序就乱了.

  4. 此外,我们发现,标记下传之后,当前结点的信息是不会变的。 也就是说,两个子树的信息先合并再应用标记,或者先应用标记再合并,得到的应该是一样的。这样就是说 $t(d_1 + d_2) = td_1 + td_2$.
  5. 最后,当我们什么都没做的时候,每个结点上的标记都是“空的”,表示什么都不做。我们把这个标记记作 $\epsilon$。它满足 $\epsilon d = d$(确实是什么都不做)。
  6. 作为一点显然的补充,我们有 $\epsilon t = t \epsilon = t$。毕竟 $\epsilon$ 就是什么都不做。

数学中,我们把配备了一个二元运算且具有结合律的结构叫做半群。那么可以发现

  • 所有信息,加上合并操作,构成了一个半群。
  • 所有标记,加上复合操作,构成了一个半群。而且它有一个“单位元” $\epsilon$,所以是幺半群(单位元也叫幺元,所以幺半群其实就是字面意思,带幺的半群)。
  • 除此之外,标记还会作用在信息上。按数学的话讲,这叫做幺半群作用。

这看上去非常的复杂!有没有简单的办法呢?比如说,我懒得想标记和信息是什么形式了,有没有暴力做法呢!

答案是:当然有!虽然很笨蛋!

“万有”双半群

想象一下,有一个问题,要求区间加和区间乘。但是我偷懒,发现按矩阵的方式,

$$\begin{bmatrix} a & b \\ 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ 1\end{bmatrix} = \begin{bmatrix} ax+b \\ 1\end{bmatrix}.$$

我想起大佬教给我:线段树可以维护矩阵乘法!

于是我每个标记都是一个 $2 \times 2$ 的矩阵,信息是 $2 \times 1$ 的向量。

可以发现这个例子里,“我”很笨蛋,明明两个数就能维护的标记,我却要用四个数维护。但是虽然笨蛋,却也找到了办法。

有没有“最笨蛋”的办法呢!笨蛋到不需要思考的办法,不需要大佬教矩阵乘法的办法。

答案是确实有。我们考虑这样的抽象的问题:

  • 维护一个序列,序列里每个元素是个抽象的数据,具有你不知道具体实现的类型 $M$.
  • 要求支持操作:给你一个黑盒函数 $M \to M$, 以及一个区间,要求你把区间里每个元素都作用上这个函数。
  • 支持查询:给你一个黑盒函数 $\operatorname{Array}(M) \to M$, 以及一个区间,要求你把这个区间提取出来传到这个函数里,然后递出去结果。这里 Array 是数组,C++ 选手可以理解为 vector<M>

可以发现,所有区间修改区间查询的线段树问题都可以规约到这个问题!(废话.jpg)那么这个问题,怎么使用线段树做呢?(虽然我知道你想说线段树不如暴力)

显然我们没有任何办法压缩标记和信息,毕竟我们对他们一无所知。那么唯一的办法就是: - 一个标记就是一个 $M \to M$ 的函数——不保证这个函数可以 $O(1)$ 执行。 因为如果传进来一个黑盒函数 $f_1$, 又传进来黑盒函数 $f_2$,那我们只能得到一个新的函数 $f(x) = f_2(f_1(x))$。不要在意具体实现——如果你真的在意的话,这可能是一个lambda表达式; 你也可以干脆认为一个标记就是一个 vector,里面装着很多黑盒函数,表示要依次执行这些函数。 - 一个信息...就是一个 $\operatorname{Array}(M)$. 没错,区间维护的信息就是区间本身。因为我们对 $M$ 一无所知,完全不能提取出更细致的东西了。很笨蛋! - 信息的合并...就是俩 Array 拼起来。 - 标记的复合...就是函数复合。 - 标记作用在信息上...就是遍历 Array 里每个元素,扔到函数里拿出来,组成新的 Array。

很笨蛋!由于我们没有任何简化,执行一个函数其实是执行输入的一系列函数的复合,有可能一次就是 $O(n)$ 的。所以只是做一次“标记作用在信息上”,就可能 $O(n^2)$ 了!最终复杂度可能是 $O(n^3)$,不过我们确实是用线段树解决的,不是吗(

为什么要提到这个笨蛋的构造呢? 事实上,这个无比憨憨的构造反而是线段树维护区间信息的某种基础,它具有某种“万有性”。

比如说,如果小明和小红分别设计了一套标记+信息的结构,而小明维护的信息以及标记完全可以实现小红的所有信息和标记(也就是说,即使不知道子树长什么样,我们也可以完全通过小明的信息,推算出小红的信息;而且小红的所有标记都可以用小明的标记实现,反过来却不一定),那我们可以认为小明的结构更强。如果这么说,上面这个笨蛋构造就比所有信息+标记结构都强!

但是古尔丹,代价是什么呢?代价当然就是比暴力还要可怜的复杂度。强度也高,复杂度也高,全面吊打

这也是为什么本文把标记的结合叫做复合——它本就是函数的复合。

事实上,这种“万有性”可以启发我们得出以下的事实:

双半群重要吗?

显然我们不可能用上面那个笨蛋构造。那么怎么办呢?

虽然不可能用,但是可以借鉴。具体地说,设前面那个构造的标记为 $T_0\ (=\{\text{所有}\ M \to M\ \text{的函数}\})$,信息为 $D_0\ (=\operatorname{Array}(M))$。

那么一般情况下:

  • 标记 $T$ 总是 $T_0$ 的一个子集。(我们只考虑数学上的函数。也就是说算法不同结果相同的函数也算同一个) 这是废话,因为一个标记总应该是把元素变成新的元素的函数。
  • 而且当然,$T$ 里的复合跟 $T_0$ 里的复合是同样的; 并且 $T$ 里的标记复合起来还应该在 $T$ 里。也就是复合的封闭性
  • 信息 $D$ 总是从 $D_0$ 计算出来的。 这也是废话,区间信息可不就是从整个区间算出来的吗。
  • 设从 $D_0$ 算出 $D$ 的函数为 $c \colon D_0 \to D$. 这个计算应该是与信息合并、标记作用是兼容的。具体地说,若 $t \in T, d, d_1, d_2 \in D_0$, 那么
    1. $c(d_1 + d_2) = c(d_1) + c(d_2)$.
    2. $c(td) = t c(d)$.

这里非平凡的事情只有三件:

第一,标记对复合是封闭的。第二第三,上面的这两个等式。

并且只要选定了 $T \subset T_0$ 以及 $D$ 和 $c \colon D_0 \to D$,满足复合的封闭性以及上面两条等式, 它们一定满足双半群性质

结合律分配律完全不用管:因为我们选取标记和信息的方式决定它们一定正确(函数复合怎么可能不结合?区间信息只要能正确合并,怎么可能不结合?)。

我们甚至会看到,这两条等式其实是平凡的:

  1. $c(d_1 + d_2) = c(d_1) + c(d_2)$. 我们把它读成:“如果知道左右区间的信息($c(d_i)$,确实可以合并得到整个区间的信息”。这也就是说,我们合并的信息是对的。
  2. $c(td) = t c(d)$。我们把它读成:“通过标记 $t$ 和之前的信息 $c(d)$,确实能得到新的信息 $c(td)$”。这也就是说,我们更新信息的方式是对的。

在具体实现中,我们一般不会把 $T$ 真的实现为函数类型——例如区间加区间乘,我们发现只需要处理形如 $f(x) = ax+b$ 的函数,之后就可以记录 $(a, b)$ 来表示 $f(x) = ax+b$ 这个函数。这样,我们就还要加两条等式:

  1. 若 $f_t$ 是标记 $t$ 所代表的函数,那么 $tc(d) = c(f_td)$。简化的标记作用在信息上总等价于原本的函数作用在区间上之后的信息。相当于说我们用简化标记代表的函数方式代表对了。
  2. $f_{t_1} \circ f_{t_2} = f_{t_1t_2}$。这无非是说我们的标记复合写对了。这里 $\circ$ 是函数复合,$(f \circ g)(x) = f(g(x))$。

我们看到,我们最终返璞归真,回到了封闭律主宰一切的时期。

到底如何构造标记与信息?

我们看到标记会影响信息,但是信息不能反过来影响标记。

既然如此,我们先看看标记要怎么构造。

标记

只看标记,唯一重要的事情就是复合的封闭性

最小的满足封闭性的标记集合是什么?无非就是输入要求的若干修改的复合。

例如此题:

维护三个整数序列 $A_i, B_i, S_i$,支持区间 $A_i += x$,区间 $B_i += x$,区间 $S_i += x \times A_i \times B_i$。

我们发现其实 $A_i, B_i, S_i$ 是绑定的。所以与其说维护三个序列,不如说维护一个序列,只不过每个元素是个三元组 $(a, b, s)$。

如果我们以任意顺序执行任意多次这三种修改,都是全局修改,最后我们打在线段树根结点的标记应该怎么样?

换句话说,很多形如 $f((a, b, s)) = (a + x, b, s)$ 或者 $f((a, b, s)) = (a, b + x, s)$ 或者 $f((a, b, s) = (a, b, s + xab)$ 的函数复合起来,会得到什么样的函数?

这个过程,应当手算,从 $(a,b,s)$ 出发,随便扔一些修改上去,直到你发现形式固定下来位置。这道题中,我们发现怎么搞也跳不出 $f((a, b, s)) = (a + x, b + y, s + z + ua + vb + wab)$ 这样的形式。

因此我们可以记一个六元组 $(x, y, z, u, v, w)$ 表示上面这个函数,至于复合就只需把两个形如上面这样的函数复合起来看看新的六个系数分别是多少。

信息

有了标记,接下来我们就可以构造信息。

首先我们要求,信息能涵盖我们的询问。

之后就是那两条等式。我们可以读作:必须能够用左右区间的信息合并出大区间的信息,必须能够从原本的信息计算出打标记后的信息。简单说就是信息可以合并、信息可以在打标记时更新。

先要求信息可以合并。例如说经典例题

维护序列,支持单点修改,维护区间最大子段和。

既然是单点修改那就没有标记,只需要想信息。为了知道一个区间的最大子段和?我们需要知道左右区间的什么信息?不难想到,除了最大子段和之外,还要知道左区间的最大后缀和,右区间的最大前缀和。所以我们要维护这两个。

这就完了么?还没有呢:注意信息合并起来形式不能变,所以子区间要维护最大前后缀和,大区间也维护。那么这需要子区间什么信息?可以发现还需要知道整个区间的总和。

最后检查一下,这样的信息确实是可以完全合并起来的。我们就大功告成。

但是有时候还会有标记。我们就还要问:为了知道打标记之后的信息,我们需要知道打标记前的什么信息?

例如上述问题中,如果我们要求支持区间加,那么埋头苦算一番,就知道要知道区间加后的最大子段和,需要知道之前的 (子段长, 子段和) 构成的凸包。

别忘了回去看看信息合并:为了知道大区间的凸包,需要知道小区间的什么?可以发现需要知道小区间的前缀和凸包,后缀和凸包,以及子段和凸包,还有整个区间和。而且这个可以完全合并了。

还得再回来:要知道打标记之后的这一大堆信息,需要知道打标记前的什么?幸运的是这次我们发现不用增加信息了,所以可以停下迭代了。

当然线段树维护这东西复杂度就爆炸了,我们这里只是借这个例子陈述这样一种模型。

结语

无论什么理论中,总有这样一个原理:越具体的事物就具有越多的性质。具体就性质多,那么抽象自然就性质少。性质少也就决定了方法少——想想那个我们一无所知的黑盒类型,黑盒修改黑盒查询,最优的算法就只是暴力。

我们这里描述的理论虽然颇费口舌,但总归是抽象的。具体的问题总要具体的分析,本文难以涵盖的细节和例外情况也大有所在。例如有些时候,标记的下传是取决于信息的。更何况,线段树也只是树状数据结构之一,后者又只是所有数据结构中的一类;不维护序列的数据结构也有所存在。但是希望本文的方法和思想,可以给读者以启发。

本文作为笔者的第一篇数据结构文章,此前打过两千字草稿,总觉不得其精髓。今日与 UOJ 友人交流一番后文思泉涌,散步时已胸有成竹,不知不觉已写到深夜。遥想当年竞赛时期的努力颇有感慨,因此也希望本文能作为各位读者前行路上的微弱灯火,助诸位在人生道路上更进一步。

Goodbye Renyin E 新年的找对 题解

2023-01-23 23:46:06 By _rqy

前排提醒:如果数学公式看不清楚,可以把鼠标悬停上去,会放大。

简要题意

给定一张 $n$ 个点的简单无向图,每个点可能有一个颜色 $c_i > 0$, 也可能没有颜色,记作 $c_i = 0$.

你需要找出尽可能多的简单路径,使得:

  • 这些路径间两两不交(不经过重复点);
  • 路径的起点与终点需要是有颜色但是颜色不同的点;
  • 除了起点终点外,路径只能经过没有颜色的点。

需要构造出答案。

$n \leqslant 300$.

背景与参考文献

这个问题是经典问题,叫做 S-path problem。

本文将陈述此问题的 $O(n^3)$ 算法。若使用更快的 $O(n^w)$ 的矩阵乘法,则可以优化到 $O(n^w)$。若将来 $w=2$,则复杂度为 $O(n^2\log n)$。

参考文献为

理论与算法

1. 问题转化

我们先把原问题转化为:

问题一:选出原图中尽量多的边,使得这些边构成森林,且森林中每棵树里最多有两个有颜色的点,并且不能有两个相同颜色的点。

这样的话,最优解中每棵树里肯定要么有一个有颜色的点,要么有两个有颜色且颜色不同的点(除非原图中某个连通块里一个有颜色的点都没有,这样的连通块显然可以直接扔掉)

所以原问题的答案一定就是总的有颜色的点的个数,减去新问题中连通块个数。而新问题中连通块个数又等于 $n$ 减去选出的边数。

因此选的边越多,得出的简单路径就越多。这些简单路径也可以由新问题里选出的边轻易构造出来。

这一步转化是容易的,我们接下来会将这个新问题再转化为一个线性代数问题。

2. 线性拟阵配对问题

这个线性代数问题如下。

问题二(线性拟阵配对): 给定 $N$ 维空间里的 $M$ 对向量,即 $2M$ 个向量 $(b_1, c_1), (b_2, c_2), \dots, (b_M, c_M)$. 你需要找出其中尽可能多对,使得这些对向量线性无关。

如果你并不知道“线性无关”的定义,建议参考OI-Wiki: 线性基 此外,OI-Wiki 中线性代数相关的其他概念也可能对你理解本文有帮助。

如何将前面的问题一转化为该问题呢?有一个非常精妙的构造如下。

我们先取一个大空间 $\mathbb{R}^{2n}$,其中每个向量 $x$ 的坐标记作 $x_1, x_2, \dots, x_{2n}$。 此外我们记 $e_i$ 为第 $i$ 个分量是 $1$, 别的分量是 $0$ 的向量。

为了方便,我们记 $x(u) = (x_{2u-1}, x_{2u})$ 为一个二维向量。

接下来对原图中每条边 $(u, v)$,我们将其对应一个二维子空间 \begin{equation} L_{u,v} = \{ x \in \mathbb{R}^{2n} \mid x(u) + x(v) = 0 \text{ 且 } \forall w \notin \{u,v\}, x(w) = 0 \} \end{equation} 也可以说是对应了两个向量 $b_{u,v} = e_{2u-1} - e_{2v-1}, c_{u,v} = e_{2u} - e_{2v}$。

如果只是这样的话,那么只有当选出的边构成一个环的时候,才会线性相关:

比如如果选出了 $x - y - z - x$ 这样三条边,那么 $b_{x,y} + b_{y,z} + b_{z,x} = 0, c_{x,y} + c_{y,z} + c_{z,x} = 0$。

为了满足我们的额外条件(每棵树最多有两个有颜色的点,并且颜色不能相同),还需要引入一个额外的东西。

对于每个颜色,我们选取 $\mathbb{R}^2$ 里的一个非零向量 $f_c$,并且他们两两不平行(即不存在 $c \neq c'$ 使得 $f_c$ 是 $f_{c'}$ 的若干倍)。 此外记 $f_0 = 0$。

对每个点 $u$,我们取一个向量 $a_u$,使得 $a_u(u) = f_{c_u}$,而 $a_u(v) = 0$.

也就是说 $a_u$ 的 $u$ 分量是二维向量 $f_u$,其他分量是 $0$。如果 $c_u = 0$ 那么也有 $a_u = 0$.

可以发现,如果我们选出了一些边,这些边连通了某两个点 $s, t$,那么就可以用这些边对应的向量加出 $L_{s, t}$ 的元素,即使 $(s, t)$ 这条边不存在或者没被选择。

那么如果我们选出的边连接了两个颜色相同的点,比如 $1$ 和 $2$,就有 $a_1 - a_2 \in L_{1, 2}$。

而如果我们选出的边连接了三个有颜色的点,比如 $1, 2, 3$ 且其颜色也是 $1, 2, 3$, 我们可以找出三个实数 $x_1, x_2, x_3$ 使得 $x_1 f_1 + x_2 f_2 + x_3 f_3 = 0$。那么总可以找出 $L_{1, 2}$ 和 $L_{1, 3}$ 里的元素,加起来恰好得到 $x_1 f_1 + x_2 f_2 + x_3 f_3$。

也就是说,如果我们强制选择 $f_1, \dots, f_n$ 这些向量(除了其中的零向量),接下来再选择一些边(以及他们对应的若干对向量 $(b_{u,v},c_{u,v})$), 那么选出的所有向量线性无关当且仅当这些边满足问题一中的条件。

怎么强制选择这些向量呢?可以考虑把所有 $b, c$ 向量对都投影到某个和他们正交的子空间上,这样就相当于已经选择这些向量了。

3. 转化的具体实现

首先,我们先(假装)求出这 $2m$ 个向量 $b_{u,v} = e_{2u-1} - e_{2v-1}, c_{u,v} = e_{2u} - e_{2v-1}$.

接下来我们做投影。注意我们其实不需要正交,只需要把他们投影到某个子空间 $W$ 上,满足 $\mathbb{R}^{2n} = W \oplus \mathrm{Span}(f_1, \dots, f_n)$(直和)即可。如果用 OIer 比较熟悉的说法,就是用这些 $f$ 对他们做高斯消元。

所以我们不妨设 $x_c = (1, -c)$,则 $f_u = e_{2u - 1} - c_u e_{2u}$。 用 $f_u$ 对某个向量做高斯消元的话,就是把这个向量的第 $2u$ 项系数加上 $c_u$ 倍的 $2u - 1$ 项系数,然后把第 $2u - 1$ 项系数置为 $0$。 也就是 v[2*u] += c[u] * v[2*u - 1]; v[2*u-1] = 0;

(然后我们也可以把第 $2u-1$ 维直接删掉,反正所有向量的这一维都是 $0$)

4. 新问题的求解

接下来我们就要求解这个线性代数问题。

我们先定义一个记号:若 $S, T \subseteq \{ 1, \dots, n \}$ 是两个子集,$M$ 是 $n \times n$ 的矩阵,那么 $M_{S, T}$ 是只取行序号属于 $S$ 列序号属于 $T$ 的位置构成的 $|S| \times |T|$ 子矩阵。如果 $S = $ 全集或者 $T = $ 全集,我们也简记作 $M_{*,T}, M_{S,*}$.

我们给出本文中四个不加证明的定理:

首先是解决线性拟阵配对问题的核心定理:

定理 [Lovász]: 给定 $N$ 维空间里的 $M$ 对列向量,即 $2M$ 个向量 $(b_1, c_1), (b_2, c_2), \dots, (b_M, c_M)$. 我们引入 $M$ 个未知数 $x_1, \dots, x_M$,并且在他们的分式域里考虑矩阵(即矩阵的每个分量可以是这些未知数的有理式)。 定义矩阵 \begin{equation} Y = \sum_{i = 1}^M x_i (b_i \wedge c_i). \end{equation} 其中 $b_i \wedge c_i = b_i c_i^T - c_i b_i^T$。 则 $\mathrm{rank}(Y) = 2k$,其中 $k$ 是问题二的答案,即最多可以选出的线性无关向量对数。

$\wedge$ 读作 wedge。 其次是让我们摆脱掉矩阵分量里的未知数的引理:

定理 [Schwartz-Zippel 引理]: 在模素数 $p$ 的意义下,若 $f(x_1, \dots, x_n)$ 是不超过 $d$ 次的非零多元多项式,那么任取 $x_1, \dots, x_n$ 为 $0, \dots, p-1$ 的随机值, $f(x_1, \dots, x_n) = 0$ 的概率不超过 $\frac{d}{p}$.

然后是让我们之后算法的重点:

定理 [Harvey]: 设 $M$ 是一个 $n \times n$ 可逆矩阵, $N = M^{-1}$。若 $\tilde{M}$ 和 $M$ 长得很像——具体地说,有一个子集 $S$, 使得除了 $\tilde{M}_{S,S} \neq M_{S,S}$ 之外 $\tilde{M}$ 的分量都和 $M$ 相等。记 $\Delta = \tilde{M} - M$。那么

  1. $\tilde{M}$ 可逆当且仅当 $I + \Delta_{S,S}N_{S,S}$ 可逆。
  2. 若第一条成立,则 $\tilde{M}^{-1} = N - N_{*,S}(I + \Delta_{S,S}N_{S,S})^{-1}\Delta_{S,S}N_{S,*}$;
  3. 若第一条成立,则 $(\tilde{M}^{-1})_{T,T} = N_{T,T} - N_{T,S}(I + \Delta_{S,S}N_{S,S})^{-1}\Delta_{S,S}N_{S,T}$。

最后是其实可能不必要的一个玩意。

定理: 若 $A$ 是斜对称矩阵,即 $A^T = -A$,而 $\mathrm{rank}(A) = r$,则存在 $|S| = r$ 使得 $A_{S, S}$ 可逆。 具体地,只要 $A$ 中 $S$ 对应的行线性无关(即 $\mathrm{rank}(A_{S,*}) = r$),就有 $A_{S,S}$ 可逆。

如果没有斜对称的条件,那么只能知道存在 $|S| = |T| = r$ 使得 $A_{S,T}$可逆。

对可能不熟悉的读者:秩 $\mathrm{rank}$ 就是 Gauss 消元后剩下的非零行数。$n \times n$ 的矩阵 $A$ 可逆等价于 $\mathrm{rank} A = n$。

首先,我们可以取一个足够大的素数 $p$,比如 $998244353$,做模数。然后我们随机选 $m$ 个数 $x_1, \dots, x_m$ 当作“未知数”。 根据 Schwartz-Zippel 引理,只要我们接下来处理的都是多项式(比如,算矩阵的秩相当于让某个子矩阵的行列式非 $0$),就基本上不会算错。

接下来,我们算出前面这个矩阵 $Y = \sum_{i = 1}^M x_i (b_i \wedge c_i)$,并计算它的秩。但是光算出这个还不行,我们还需要找到方案。

为了方便,我们可以求出 $Y$ 的最大行向量线性无关组 $S_0$, 那么由于 $Y$ 斜对称,就知道 $Y_{S_0, S_0}$ 可逆,因此只需要关心这些行列即可。 也就是说接下来我们把所有 $b_i, c_i$ 向量里其他分量都扔掉,只留下 $S_0$ 对应的这些维分量。 这样的话 $Y$ 就可逆,我们可以算出他的逆 $N = Y^{-1}$。

我们找到方案的办法非常简单粗暴:对于每一对 $(b_i, c_i)$,我们把 $x_i (b_i \wedge c_i)$ 从 $Y$ 里减去,看看它是否还可逆。如果可逆,就删掉这一对。否则留下他(把 $x_i (b_i \wedge c_i)$ 再加回去)。

但是这样的话每次判断是否可逆的复杂度,即 Gauss 消元,是 $O(n^3)$,这肯定不行。怎么办呢?

如果是一般的情况,其实有 Sherman-Morrison-Woodbury 定理,可以做到每次 $O(n^2)$,总复杂度 $O(mn^2)$。不过本题中这个复杂度还是不行。

5. 最后的算法

我们发现其实在本题我们有一个很好的性质,那就是 $b_i$ 和 $c_i$ 最多只有最多两个分量非 $0$。所以 $x_i (b_i \wedge c_i)$ 只影响一个很小的子矩阵 $Y_{S, S}$。

因此我们可以使用 Harvey 定理——等一下,$Y$ 本来也不一定可逆欸。不过没关系, 回归正题,由于 $x_i (b_i \wedge c_i)$ 只影响一个很小的子矩阵,可以快速用 Harvey 定理判断更新后是否仍然满秩(可逆),如果可逆还可以快速求出更新后的逆。 不过这样,复杂度也还是 $O(mn^2)$。怎么进一步优化呢?

我们可以采用分治算法:如果我们有一堆边,他们对应的 $S$ (也就是他们连接的顶点对应的那些维)并起来是 $T$,而且这个 $T$ 不是很大的话, 我们就可以对他们统一更新:我们发现 Harvey 定理里判断去掉这些边之后是否合法只需要用到 $N_{T,T}$。所以我们可以只更新 $N_{T,T}$,不更新 $N_{*,*}$。 待到这些边都更新完之后,我们再去重新更新全部的 $N$。

如果把这个思路用到极致,就可以写出一个分治算法(伪代码):

SOLVE(R, C):
  记 S = R 并 C
  // 执行时,要求 N_(S, S) 是正确的
  // 执行后,已经尝试删除了所有一端点属于 R 而另一端点属于 C 的边

  IF |R| > 2 or |C| > 2:
   将 R 划分为 R1, R2
   将 C 划分为 C1, C2
   FOR (i, j) in { (1, 1), (1, 2), (2, 1), (2, 2) }:
     SOLVE(Ri, Cj)
     使用 Harvery 定理更新 N_(S, S)
  ELSE IF R = { 2u-1, 2u }, C = { 2v-1, 2v } 且存在左端点为 u 右端点为 v 的边:
      使用 Harvery 定理判断是否可以删掉这条边
      如果是,删掉
      // 不需要更新 N,因为递归上去之后会更新

注意伪代码里默认了结点 $u$ 对应的行列还是 $2u-1, 2u$。事实上由于我们删了一些行列,可能未必如此。所以分治需要注意不要把一个点对应的两个列分开。 笔者的实现里是直接对结点分治,而非对矩阵行列分治。

这样的话,复杂度为 $T(n) = 4T(\frac{n}{2}) + O(n^w)$。根据主定理可以计算出 $T(n) = O(n^w)$。如果 $w = 2$,那么 $T(n) = O(n^2 \log n)$。

代码

由于这个矩阵是 $2n \times 2n$(如果大部分点都没有颜色并且答案几乎是满的),所以常数有点大。

笔者和出题人码风不通,没看懂他的实现。笔者本人的实现最后使用了 $O(n^3)$ 算法,不过模数取用了 $10^8 + 7$,这样可以在矩阵乘法时全部乘完加完最后取模,降低了很多常数。

#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>

typedef long long LL;
const int mod = 100000007;
const int N = 303;
const int NN = 606;
const int M = N * N / 2;

LL pow_mod(LL a, LL b) {
  LL ans = 1;
  for (; b; b >>= 1, a = a * a % mod)
    if (b & 1) ans = ans * a % mod;
  return ans;
}

inline void mul(int m, LL *A, LL x) {
  for (int i = 0; i < m; ++i) A[i] = A[i] * x % mod;
}

inline void add(int m, LL *A, const LL *B, LL x) {
  for (int i = 0; i < m; ++i) A[i] = (A[i] + x * B[i]) % mod;
}

inline void swap(int m, LL *A, LL *B) {
  for (int i = 0; i < m; ++i) std::swap(A[i], B[i]);
}

void rank(int n, int m, LL A[NN][NN], int &r, int *S) {
  // A[S][*] has rank r.
  static int id[NN];
  for (int i = 0; i < n; ++i) id[i] = i;
  for (int i = 0, j = 0; j < m; ++j) {
    int k = i;
    while (k < n && A[k][j] == 0) ++k;
    if (k == n) continue;
    if (i != k) {
      std::swap(id[i], id[k]);
      swap(m, A[i], A[k]);
    }
    S[r++] = id[i];
    LL t = pow_mod(A[i][j], mod - 2);
    for (int k = i + 1; k < n; ++k)
      add(m, A[k], A[i], -t * A[k][j] % mod);
    ++i;
  }
}

bool inv(int n, const LL A_[NN][NN], LL B[NN][NN]) {
  static LL A[NN][NN];
  // return true iff A is invertible.
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
      A[i][j] = A_[i][j], B[i][j] = i == j;
  for (int i = 0; i < n; ++i) {
    int k = i;
    while (k < n && A[k][i] == 0) ++k;
    if (k == n) return false;
    if (i != k) {
      swap(n, A[i], A[k]);
      swap(n, B[i], B[k]);
    }
    LL t = pow_mod(A[i][i], mod - 2);
    mul(n, A[i], t); mul(n, B[i], t);
    for (int k = 0; k < n; ++k) if (k != i) {
      add(n, B[k], B[i], -A[k][i] % mod);
      add(n, A[k], A[i], -A[k][i] % mod);
    }
  }
  return true;
}

void mul(int n, int m, int r, const LL A[NN][NN], const LL B[NN][NN], LL C[NN][NN]) {
  static LL tmp[NN][NN];
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < r; ++j) tmp[i][j] = 0;
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < m; ++j)
      for (int k = 0; k < r; ++k)
        tmp[i][k] = tmp[i][k] + A[i][j] * B[j][k];
  for (int i = 0; i < n; ++i)
    for (int j = 0; j < r; ++j) C[i][j] = tmp[i][j] % mod;
}

#define take(A, X, Y, FORMULA)                     \
  for (int i = 0; i < X; ++i)                   \
    for (int j = 0; j < Y; ++j)                 \
      A[i][j] = FORMULA

bool modify(const LL A[NN][NN], LL B[NN][NN], const LL A1[NN][NN],
            int s, const int *S, int t, const int *T) {
  // assuming B = A^(-1) and A[i][j] != A1 only when i, j in S
  // if A1 is singular, return false
  // else:
  // modify B such that B_(T, T) = A1^(-1)_(T, T)
  // only use A[S][S], A1[S][S], B[T][T].
  static LL D[NN][NN], C[NN][NN], P[NN][NN];
  take(D, s, s, A1[S[i]][S[j]] - A[S[i]][S[j]]);
  take(P, s, s, B[S[i]][S[j]]);
  mul(s, s, s, D, P, P);
  for (int i = 0; i < s; ++i) (++P[i][i]) %= mod;
  if (!inv(s, P, C)) return false;
  mul(s, s, s, C, D, C);
  take(P, t, s, B[T[i]][S[j]]);
  mul(t, s, s, P, C, C);
  take(P, s, t, B[S[i]][T[j]]);
  mul(t, s, t, C, P, C);
  for (int i = 0; i < t; ++i)
    for (int j = 0; j < t; ++j) {
      int ti = T[i], tj = T[j];
      B[ti][tj] = (B[ti][tj] - C[i][j]) % mod;
    }
  return true;
}

int C[N], L[N];
LL X[N][N];
bool link[N][N];
int n, nn;
int id[NN];
int t, T[NN];

void Add(LL A[NN][NN], int u, int v, int i, int j, LL p) {
  int x = L[u] + i, y = L[v] + j;
  if (C[u] && i) x = L[u], p = p * C[u] % mod;
  if (C[v] && j) y = L[v], p = p * C[v] % mod;
  if (id[x] >= 0 && id[y] >= 0) A[id[x]][id[y]] = (A[id[x]][id[y]] + p) % mod;
}

inline void Add(LL A[NN][NN], int u, int v, LL p) {
  // b = e(u0) - e(v0)
  // c = e(u1) - e(v1)
  // A += p (b wedge c)
  Add(A, u, u, 0, 1, p);
  Add(A, u, v, 0, 1, -p);
  Add(A, v, u, 0, 1, -p);
  Add(A, v, v, 0, 1, p);
  Add(A, u, u, 1, 0, -p);
  Add(A, u, v, 1, 0, p);
  Add(A, v, u, 1, 0, p);
  Add(A, v, v, 1, 0, -p);
}

LL Y[NN][NN], Z[NN][NN];

int ss[10], SS[10][NN];
LL AA[10][NN][NN], BB[10][NN][NN];

bool solve(int l1, int r1, int l2, int r2, int dep) {
#define copyS(U, V)                             \
  for (int i = 0; i < s; ++i)                   \
    for (int j = 0; j < s; ++j)                 \
      U[S[i]][S[j]] = V[S[i]][S[j]]
  if (r1 < l1 || r2 < l2) return false;
  int &s = ss[dep] = 0;
  int *S = SS[dep];
  LL (*A)[NN] = AA[dep], (*B)[NN] = BB[dep];
  for (int i = L[l1]; i < L[r1 + 1]; ++i) if (id[i] >= 0) S[s++] = id[i];
  for (int i = L[l2]; i < L[r2 + 1]; ++i) if (id[i] >= 0) S[s++] = id[i];
  std::sort(S, S + s); s = std::unique(S, S + s) - S;
  copyS(A, Y);
  copyS(B, Z);

  if (r1 == l1 && r2 == l2) {
    if (!link[l1][l2]) return false;
    Add(A, l1, l2, -X[l1][l2]);
    if (!modify(Y, B, A, s, S, 0, NULL)) return false;
    link[l1][l2] = false;
    Add(Y, l1, l2, -X[l1][l2]);
    return true;
  }

  int m1 = (l1 + r1) / 2, m2 = (l2 + r2) / 2;
  bool changed = false;
  for (int a = 0; a < 2; ++a)
    for (int b = 0; b < 2; ++b) {
      int u1 = l1, v1 = m1, u2 = l2, v2 = m2;
      if (a) u1 = m1 + 1, v1 = r1;
      if (b) u2 = m2 + 1, v2 = r2;
      if (solve(u1, v1, u2, v2, dep + 1)) {
        changed = true;
        // fprintf(stderr, "%d %d\n", ss[dep + 1], s);
        if (ss[dep + 1] != s) {
          if (false) {
            inv(s, Y, Z);
          } else {
            copyS(Z, B);
            modify(A, Z, Y, ss[dep + 1], SS[dep + 1], s, S);
          }
        }
        copyS(A, Y);
        copyS(B, Z);
      }
    }
  return changed;
}

bool vis[N];
int path[N][N], len[N], ans;

bool dfs(int x, bool rt) {
  vis[x] = true;
  if (C[x] && !rt) {
    ++ans;
    path[ans][len[ans]++] = x;
    return true;
  }
  for (int y = 1; y <= n; ++y)
    if ((link[x][y] || link[y][x]) && !vis[y]) {
      int t = dfs(y, false);
      if (t) {
        path[ans][len[ans]++] = x;
        return true;
      }
    }
  return false;
}

int main() {
  int m;
  scanf("%d%d", &n, &m);
  for (int i = 1; i <= n; ++i) {
    scanf("%d", &C[i]);
    L[i] = nn++;
    if (!C[i]) nn++;
  }
  L[n + 1] = nn;
  for (int i = 0; i < nn; ++i) id[i] = i;
  for (int i = 0; i < m; ++i) {
    int u, v;
    scanf("%d%d", &u, &v);
    if (u == v || link[u][v] || link[v][u]) continue;
    link[u][v] = true;
    X[u][v] = rand() % mod; // TODO: ERROR
    Add(Y, u, v, X[u][v]);
  }
  rank(nn, nn, Y, t, T);
  memset(id, -1, sizeof id);
  for (int i = 0; i < t; ++i) id[T[i]] = i;
  memset(Y, 0, sizeof Y);
  for (int u = 1; u <= n; ++u)
    for (int v = 1; v <= n; ++v)
      if (link[u][v]) Add(Y, u, v, X[u][v]);
  inv(nn, Y, Z);
  solve(1, n, 1, n, 0);
  for (int i = 1; i <= n; ++i)
    if (!vis[i] && C[i]) dfs(i, true);
  printf("%d\n", ans);
  for (int i = 1; i <= ans; ++i) {
    printf("%d", len[i]);
    for (int j = 0; j < len[i]; ++j)
      printf(" %d", path[i][j]);
    puts("");
  }
  return 0;
}

$E为什么可以5e5多点求值(详细揭秘)

2020-05-23 00:09:06 By _rqy

大家可能都知道可以通过多项式取模来做多点求值(i.e.$f(\alpha)=f(x)\bmod(x-\alpha)$)。

我们考虑这个多点求值到底是个什么东西:

对于一个 $m-1$ 次多项式 $f(x)=\sum_{i=0}^{n-1}f_ix^i$,我们将其看做一个 $m$ 维列向量 $f=(f_0,f_1,\dots,f_{m-1})^T$。($-^T$ 表示转置)。那么我们求的是:对于每个 $i\in[0,n)$ 求 $\sum_{j=0}^{n-1}f_j\alpha_i^j$。换句话说我们要求

$$ \begin{pmatrix} 1&\alpha_0&\alpha_0^2&\cdots&\alpha_0^{m-1}\\ 1&\alpha_1&\alpha_1^2&\cdots&\alpha_1^{m-1}\\ 1&\alpha_2&\alpha_2^2&\cdots&\alpha_2^{m-1}\\ \vdots&\vdots&\vdots&\ddots&\vdots\\ 1&\alpha_{n-1}&\alpha_{n-1}^2&\cdots&\alpha_{n-1}^{m-1}\\ \end{pmatrix}f $$

固定 $\alpha_0,\dots\alpha_{n-1}$,左边其实就是一个范德蒙矩阵(Vandermonde Matrix),记它为 ${\bf V}$。

所以我们要做的其实就是要求:将一个向量左乘一个范德蒙矩阵。

这个东西不太好搞;但是如果考虑左乘它的转置( $u\mapsto {\bf V}^Tu$),那么这个我们是知道如何去做的:把式子展开,我们要求的是:对每个 $i\in[0,m)$,求出 $\sum_{j=0}^{n-1}u_j\alpha_j^i$。换句话说,我们要求 $\sum_{j=0}^{n-1}\frac{u_j}{1-\alpha_jx}$ 的前 $m$ 项。这个东西我们很会做:这就是分治 FFT 上去。

那么我们已知了如何从 $u$ 去求 ${\bf V}^Tu$。这对我们的目标(从 $f$ 求出 ${\bf V}f$)有什么帮助?

Tellegen's Principle

我们发现 $u\mapsto {\bf V}^Tu$ 这个计算是线性的(假设 $n,\alpha_0,\dots,\alpha_{n-1}$ 给定,把 $u_0\dots u_{n-1}$ 看作输入的话,可以发现整个算法中仅有加减以及乘除常数)。这也是很自然的,因为要计算的东西本来就正好是 $u$ 左乘一个矩阵。

既然这样,我们把 $u\mapsto {\bf V}^Tu$ 的整个程序展开,就一定会得到这样的一个过程:每一步都是如下三种操作的一种:

  • ${\bf x}\mathrel{+}=c{\bf y}$($\bf x, y$ 表示程序中的变量,$c$ 是某个和输入无关的常数,下同);
  • ${\bf x}\mathrel{*}=c$;
  • ${\rm swap}({\bf x},{\bf y})$(交换两个变量的值)。

注意这里我们一直认为 $n,\alpha_0,\dots,\alpha_{n-1}$ 是固定的,即不把它们看做输入。

这里其实我们就是将 ${\bf V}^T$ 分解成了若干个初等行变换矩阵的乘积 ${\bf V}^T=E_kE_{k-1}\dots E_1$($k$ 表示把整个算法展开之后会有多少步)。因此我们就有 ${\bf V}=E_1^TE_2^T\dots E_k^T$;换句话说,我们只需要把上面每一种操作也“转置”过来,并把整个过程“反过来”,那么我们就得到了一个 $f\mapsto {\bf V}f$ 的算法;这里,第一种操作转置过来就会变成 ${\bf y}\mathrel{+}=c{\bf x}$;后两种操作转置过来是不变的。

这个操作(通过 $u\mapsto{\bf A}u$ 的算法来得到 $f\mapsto{\bf A}^Tf$ 的算法)被称作“转置原理”,或“特勒根原理”(Tellegen's Principle)。

举个例子:如果我们的输入是 $(f_0,f_1,f_2)$,输出是 $(f_0,f_0+f_1,f_0+f_1+f_2)$,那么有这样一个算法:$f_1\mathrel{+}=f_0;\;f_2\mathrel{+}=f_1$。转置过来之后的输出是 $(f_0+f_1+f_2,f_1+f_2,f_2)$,对应的算法是 $f_1\mathrel{+}=f_2;\;f_0\mathrel{+}=f_1$。

这样的话,我们至少可以“劝自己相信存在某个和原来一样复杂度一样常数的算法”,毕竟我们会做同样多次的加减法呢、同样多次的乘法。

实现

但是一个问题是,你显然不会蠢到在程序里面手动实现一个转置原理(比如,把你用到的加减乘除操作全都丢到一个队列里然后反过来做,虽然这是完全可行的)。当然你可以手动把程序反过来写,但是比较好的方法是手动分析这里用到的各种算法的”转置“是什么样的。

多项式乘法

首先肯定要考虑多项式乘法。固定一个 $n$ 次多项式 $g$,然后把一个 $m$ 次多项式 $f$ 看做输入,那么我们应该得到一个 $n+m$ 次多项式 $ans$。这个线性变换就是对每对 $i,j$,把 $g_i$ 倍的 $f_j$ 加到 $ans_{i+j}$ 上。因此它的转置应该是:把一个 $n+m$ 次多项式 $f$ 作为输入,得到一个 $n$ 次多项式 $ans$;然后对每对 $i,j$,把 $g_i$ 倍的 $f_{i+j}$ 加到 $ans_j$ 上(这个过程其实很简单,你只需要记住把“输入”到“输出”的系数反过来就好了)。因此我们得到的式子就是 $ans_j=\sum_ig_if_{i+j}$,其中 $ans$ 的次数不低于 $f,g$ 的次数之差(“不低于”是因为我们可能会把 $f$ 看做超过 $n+m$ 次多项式,因为我们正着 FFT 的时候会算 $n+m$ 次但只保留前若干项)。

这个过程可以通过把 $g$ 反过来 FFT 一次实现,我们把这样得到的结果 $ans$ 写作 $g\times^T_nf$(上标 $T$ 表示转置,而下标标出了结果需要保留多少次)。

多点求值

考虑我们计算 ${\bf V}^Tf$ 的算法:要计算 $\sum_{i=0}^{n-1}\frac{u_i}{1-\alpha_ix}$,记 $g(x)=\prod_{i=0}^{n-1}(1-\alpha_ix)$,然后我们计算 $\sum_{i=0}^{n-1}f_i\frac{g(x)}{1-\alpha_ix}$,最后再把它乘上 $g^{-1}(x)$。首先我们先利用分治乘法来计算 $g(x)$。由于这部分并不依赖于输入,所以它不属于转置的范畴。(再强调一次我们只把 $u_0,\dots,u_{n-1}$ 看作输入)

对于 $\sum_{i=0}^{n-1}u_i\frac{p(x)}{1-\alpha_ix}$,我们采用分治:

  • 若 $n=1$,则直接返回 $f_0$。
  • 若 $n>1$,令 $m=\lfloor n/2\rfloor$,递归计算左边($u_{0,\dots,m-1}$)的答案 $ans_L$ 和右边($u_{m,\dots,n-1}$)的答案 $ans_R$;然后返回 $ans_Lg_R+ans_Rg_L$;其中 $g_L,g_R$ 是两边对应的 $g$,在最开始的分治里面已经计算过了。

若这一步得到的答案记为 $h$,那么最终的答案(${\bf V}^Tu$)就是 $g^{-1}\times h$。

将这个算法转置过来,不要忘记要把操作的顺序也反过来:

首先仍然是分治计算 $g$,这一步是不变的;然后对于输入的 $f$,我们计算 $h=g^{-1}\times^T_nf$(注意,考虑到前面我们的 $h$ 有 $n$ 位,所以这里的 $h$ 也应该保留 $n$ 位。但是前面的 $g^{-1}$ 要计算 $m$ 位,所以这里的 $g^{-1}$ 也应该是计算完整的 $m$ 位)。

接下来把前面所说的分治转置过来:

  • 若 $n=1$,直接返回 $h_0$;
  • 若 $n>1$,令 $m=\lfloor n/2\rfloor$,然后记 $h_L=g_R\times^T_mh,h_R=g_L\times^T_{n-m}h$,然后分别把 $h_L,h_R$ 向两边递归。

这样我们就可以得到 ${\bf V}f$,即多点插值结果。

此外,在计算的时候不难发现 $\times^T$ 的操作所需要的 FFT 长度是和原来相同的(因为我们反过来卷积后只需要后面一半,所以 FFT 短一些,循环卷积溢出到前面那一半我们也用不到),所以这样的常数和前面的是相同的;我们只需要一次多项式求逆,别的都只是普通的fft。这相比于原来(每一步都要做一个多项式取模)快了不少。

然而 $E 在此基础上加了很多优化,所以他可以 5e5 多点求值。

共 6 篇博客